教程区块链区块链基础知识第17章 投票合约设计与 Hardhat 工程化

本页目录

在完成了对智能合约安全基础(重入、溢出、访问控制)的理解之后,我们终于要将这些知识融入一个完整的应用级场景——去中心化投票系统。投票是区块链最早被看好的落地场景之一:它天然需要不可篡改的记票本透明可查的计票规则,而这正是智能合约的核心优势。

本章将带领你经历一次从业务需求拆解合约代码落地再到Hardhat 工程化部署的完整闭环。你将亲手编写一个支持管理员注册候选人、授权选民身份、防重入投票、实时计票的 Vote.sol 合约,并借助 Hardhat 工具链在本地网络完成部署与交互验证。后续章节会在此基础上搭建 React 前端,形成完整的 DApp。

17.1 需求分析与智能合约设计

17.1.1 投票场景的业务需求拆解

一个可信的链上投票系统,至少需要满足以下五条业务规则:

  1. 管理员创建选举:由可信的管理员初始化选举主题,并注册候选人名单。
  2. 选民身份注册:投票前,管理员或授权机构将合格选民的地址录入白名单,防止无关地址参与。
  3. 一人一票制:每个被授权的选民在整个选举周期中只能投出一张选票,且不可撤回或修改。
  4. 结果实时可见:每位候选人的得票数对全网络公开透明,任何人都可以验证计票结果。
  5. 生命周期管理:选举应具有明确的开始与结束状态(本章先做简化版,后续扩展为时间锁控制)。

这些需求看似朴素,但在去信任环境中,每一条都需要用代码强制约束,因为链上不存在现实中的「检票员」。

17.1.2 威胁模型与安全假设

在设计合约之前,我们先建立威胁模型(Threat Model),明确「敌人会怎么做」。

威胁一:重复投票

  • 场景:选民 A 在投完票后再次调用 vote() 函数。
  • 危害:攻击者或恶意节点可通过大量重复投票人为放大某候选人的得票数,导致结果失真。
  • 防御:在合约中维护 mapping(address => bool) voted,在投票函数入口处通过 require(!voted[msg.sender]) 强制回滚。

威胁二:非授权投票

  • 场景:地址 B 从未被管理员授权,却直接调用 vote()
  • 危害:破坏身份验证机制,使投票结果失去代表性。
  • 防御:维护 mapping(address => bool) authorizedVoters 白名单,投票前双重校验——既查身份又查是否已投票。

威胁三:管理员作恶

  • 场景:管理员在选举进行中擅自修改候选人名单、取消已投选票,甚至自己伪造投票。
  • 危害:中心化风险违背区块链「去信任」的设计初衷。如果管理员是一把没有制约的私钥,整个系统就变成「链上 Excel 表格」。
  • 缓解思路:生产环境应引入多签钱包(如 Gnosis Safe)管理 owner 权限,或将关键操作交由 DAO 治理合约投票决定。本章为教学清晰,先以 OpenZeppelin 的 Ownable 做简化实现,后续章节再做扩展。

17.1.3 合约架构设计

基于以上需求与威胁分析,合约由三个核心模块构成:

  • Election 主合约:管理选举元数据(名称、状态、候选人列表、选民注册表),作为整个系统的调度中心。
  • Candidate 数据结构struct Candidate { uint id; string name; uint voteCount; }。使用 id 作为键值索引,既方便前端快速查询,也节省遍历候选人的 Gas。
  • VoterRegistry 身份映射mapping(address => bool) authorizedVoters 记录授权白名单,mapping(address => bool) voted 记录已投票状态。

下图展示了投票 DApp 的权限模型与核心数据流:

flowchart LR
    A[管理员] -->|创建选举 / 注册候选| C[智能合约 Vote.sol]
    A -->|授权选民地址| C
    B[授权选民] -->|vote(candidateId)| C
    C -->|记录投票状态| D[链上存储]
    D -->|实时查询得票| E[任何人]
    C -->|emit Voted| F[前端 / 监听器]

如图,管理员负责初始化与授权,选民只能执行一次受限的投票操作,而合约状态对所有参与者公开透明。这样的设计将「信任」从某个中心化机构转移到了可审计的代码逻辑上。

本节要点

  • 投票合约的核心需求是「身份认证、一人一票、公开计票」。
  • 威胁模型告诉我们:防御重复投票、非授权投票与中心化管理员作恶是设计重点。
  • 合约架构围绕 Election(调度)、Candidate(数据)与 VoterRegistry(身份)三层展开。

17.2 编写投票合约:候选人注册、投票与防重入

17.2.1 核心状态变量与数据结构

合约需要以下关键状态:

变量类型用途
candidatesCountuint候选人总数,作为自增 ID 分配器
candidates[id]mapping(uint => Candidate)按 ID 索引候选人信息
voted[addr]mapping(address => bool)记录某地址是否已投票
authorizedVoters[addr]mapping(address => bool)授权白名单
owneraddress管理员地址,继承自 OpenZeppelin 的 Ownable

其中 Candidate 结构体定义为:

solidity
struct Candidate {
    uint id;
    string name;
    uint voteCount;
}

17.2.2 访问控制:onlyOwner 与 OpenZeppelin 继承

许多初学者会手写一个 modifier onlyOwner,但更好的方案是直接继承 OpenZeppelin 的 Ownable.sol

solidity
import "@openzeppelin/contracts/access/Ownable.sol";
contract Vote is Ownable {
    // 自动生成 owner 与 onlyOwner 修饰符
}

这样做的好处有三重:经过社区审计的代码更安全;未来迁移到 AccessControl 做细粒度角色管理时路径清晰;代码语义对阅读者更友好。

17.2.3 投票函数完整流程设计

投票函数 vote(uint _candidateId) 必须按如下严格顺序执行——这一顺序是防御重入攻击(Reentrancy)的关键:

  1. 资格审查require(authorizedVoters[msg.sender], "Not authorized");
  2. 防重复投票require(!voted[msg.sender], "Already voted");
  3. 标记已投票voted[msg.sender] = true; ——先更新状态
  4. 计票增加candidates[_candidateId].voteCount++;
  5. 触发事件emit Voted(msg.sender, _candidateId);

⚠️ 为什么是「先改状态、再发事件、最后外部调用」?

虽然本章的投票函数不涉及向用户转账,但如果未来扩展为「投票即获得代币奖励」,函数末尾的 transfercall 就可能被攻击者利用重入漏洞回调 vote()。若状态更新放在外部调用之后,攻击者在回调时仍满足 !voted[msg.sender],从而可以无限循环投票。将状态更新置于最前面,是 Solidity 安全编程的黄金铁律——Checks-Effects-Interactions 模式

17.2.4 管理员函数与查询接口

  • 构造函数constructor(string memory _electionName) Ownable(msg.sender),初始化选举名称并设定部署者为管理员。
  • 注册候选人addCandidate(string memory _name),仅 onlyOwner 可调用,内部将 candidatesCount 自增并写入新候选信息。
  • 授权选民authorizeVoter(address _voter),将地址加入白名单。
  • 查询候选人getCandidate(uint _id) public view returns (Candidate memory),供前端读取数据。

17.2.5 完整投票合约代码(完整可运行)

以下是一段基于 Solidity 0.8.19、完整继承 OpenZeppelin Ownable 的投票合约,附带 natspec 风格注释,可直接放入 contracts/Vote.sol

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/access/Ownable.sol";

/// @title 去中心化投票合约
/// @author 教程作者
/// @notice 支持管理员注册候选人、授权选民,并确保每人只能投一票
/// @dev 基于 OpenZeppelin Ownable 做访问控制
contract Vote is Ownable {

    /// @notice 候选人数据结构
    struct Candidate {
        uint id;
        string name;
        uint voteCount;
    }

    /// @notice 选举名称
    string public electionName;

    /// @notice 候选人总数,自动递增作为 ID 分配器
    uint public candidatesCount;

    /// @notice 候选人索引映射,id => Candidate
    mapping(uint => Candidate) public candidates;

    /// @notice 记录某地址是否已投票
    mapping(address => bool) public voted;

    /// @notice 授权选民白名单
    mapping(address => bool) public authorizedVoters;

    /// @notice 投票事件,用于前端实时监听
    /// @param voter 投票者地址
    /// @param candidateId 所投候选人 ID
    event Voted(address indexed voter, uint indexed candidateId);

    /// @notice 注册候选人事件
    /// @param candidateId 候选人 ID
    /// @param name 候选人名称
    event CandidateAdded(uint indexed candidateId, string name);

    /// @notice 选民授权事件
    /// @param voter 被授权地址
    event VoterAuthorized(address indexed voter);

    /// @param _electionName 选举主题名称
    constructor(string memory _electionName) Ownable(msg.sender) {
        electionName = _electionName;
    }

    /// @notice 管理员注册新候选人
    /// @param _name 候选人名称
    function addCandidate(string memory _name) public onlyOwner {
        candidatesCount++;
        candidates[candidatesCount] = Candidate(candidatesCount, _name, 0);
        emit CandidateAdded(candidatesCount, _name);
    }

    /// @notice 管理员将地址加入授权选民白名单
    /// @param _voter 待授权的以太坊地址
    function authorizeVoter(address _voter) public onlyOwner {
        authorizedVoters[_voter] = true;
        emit VoterAuthorized(_voter);
    }

    /// @notice 授权选民为指定候选人投票,每人只能投一次
    /// @param _candidateId 候选人 ID(1-based)
    function vote(uint _candidateId) public {
        // Step 1: 身份校验
        require(authorizedVoters[msg.sender], "Vote: caller is not an authorized voter");

        // Step 2: 防重复投票
        require(!voted[msg.sender], "Vote: already voted");

        // Step 3: 候选人存在性校验
        require(_candidateId > 0 && _candidateId <= candidatesCount, "Vote: invalid candidate ID");

        // Step 4: 先更新状态(Checks-Effects)
        voted[msg.sender] = true;

        // Step 5: 计票增加
        candidates[_candidateId].voteCount++;

        // Step 6: 触发事件(Interactions 的最后一步)
        emit Voted(msg.sender, _candidateId);
    }

    /// @notice 按 ID 查询候选人信息
    /// @param _id 候选人 ID
    /// @return 候选人结构体
    function getCandidate(uint _id) public view returns (Candidate memory) {
        require(_id > 0 && _id <= candidatesCount, "Vote: invalid candidate ID");
        return candidates[_id];
    }
}

这段代码遵循了安全编码的三大原则:

  1. 单一入口约束:所有状态变更只能由 vote() 函数一次触发,不存在旁路修改票数的后门。
  2. 先校验后修改require 语句在前,确保非法请求在 Gas 消耗最小的阶段被回退。
  3. 事件驱动透明:每一次投票都会触发 Voted 事件,前端可以通过 ethers.jscontract.on("Voted", ...) 实时更新计票面板。

本节要点

  • 核心状态由 candidatesvotedauthorizedVoters 三组映射共同维护。
  • 投票函数必须遵循 Checks → Effects → Interactions 顺序,这是防重入的黄金铁律。
  • OpenZeppelin Ownable 让访问控制更简洁、更安全、更可维护。
  • natspec 注释不仅是文档,还能被 Etherscan 与开发者工具自动解析。

17.3 Hardhat 工程化:编译、部署与脚本编写

17.3.1 项目初始化与依赖安装

将合约代码落地为可运行、可测试、可部署的工程,需要一套完整的开发工具链。本章选用 Hardhat——它内置了以太网模拟节点、任务系统与插件生态,是目前以太坊开发者最主流的工程化框架。

创建并初始化项目的命令如下:

bash
mkdir voting-dapp && cd voting-dapp
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npx hardhat init   # 选择「Create a TypeScript project」或 JavaScript 项目
npm install @openzeppelin/contracts

初始化后的项目目录结构如下:

text
voting-dapp/
├── contracts/
│   └── Vote.sol          # 将 17.2.5 的合约代码放入此处
├── scripts/
│   └── deploy.js         # 部署脚本
├── test/
│   └── Vote.test.js      # 单元测试(后续章节展开)
├── hardhat.config.js     # 编译器与网络配置
├── package.json
└── node_modules/

下图展示了 Hardhat 项目内各目录的职责与工作流程:

flowchart TD
    A[contracts/ 合约源码] -->|hardhat compile| B[编译产物 artifacts/]
    B --> C[scripts/ 部署脚本]
    C -->|npx hardhat run| D[Hardhat Network 本地节点]
    E[test/ 单元测试] -->|npx hardhat test| D
    D -->|实时反馈| F[开发者控制台]

contracts/ 保存 .sol 源码,scripts/ 负责部署与初始化,test/ 做断言验证,而 Hardhat Network 为这一切提供极速的本地执行环境,不需要等待真实区块确认。

17.3.2 配置 hardhat.config.js

为了让 Hardhat 正确编译 0.8.19 版本的 Solidity 代码,我们需要在配置文件中指定编译器版本,并开启优化器以降低部署 Gas:

javascript
// hardhat.config.js
require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
  solidity: {
    version: "0.8.19",
    settings: {
      optimizer: {
        enabled: true,
        runs: 200,
      },
    },
  },
  // 默认网络为内置的 Hardhat Network,无需额外配置即可测试
  // 后续部署到 Sepolia 等测试网时,可在此添加 network 配置
};

optimizer 的作用是压缩合约字节码体积并减少运行时 Gas。runs: 200 表示优化器重排代码以假设函数会被调用约 200 次,这是兼顾部署成本与执行成本的常用参数。

17.3.3 编写部署脚本

部署脚本 scripts/deploy.js 的职责不仅是「部署一份合约」,更包括预填充初始化数据——注册候选人与授权测试选民,让后续手动交互时可以直接投票,而不必重复打字。

javascript
// scripts/deploy.js
const hre = require("hardhat");

async function main() {
  // 获取部署者钱包地址
  const [deployer, voter1, voter2] = await hre.ethers.getSigners();
  console.log("部署者地址:", deployer.address);

  // 1. 编译并获取合约工厂
  const VoteFactory = await hre.ethers.getContractFactory("Vote");

  // 2. 部署合约,传入构造函数参数
  const voteContract = await VoteFactory.deploy("2024 社区治理选举");

  // 3. 等待部署上链
  await voteContract.waitForDeployment();
  console.log("投票合约已部署至:", await voteContract.getAddress());

  // 4. 管理员操作:注册候选人
  await (await voteContract.addCandidate("Alice - 建设更多公共节点")).wait();
  await (await voteContract.addCandidate("Bob - 优化 Gas 费分配")).wait();
  await (await voteContract.addCandidate("Charlie - 引入 DAO 治理")).wait();
  console.log("已注册 3 名候选人");

  // 5. 管理员操作:授权测试选民
  await (await voteContract.authorizeVoter(voter1.address)).wait();
  await (await voteContract.authorizeVoter(voter2.address)).wait();
  console.log("已授权测试选民:", voter1.address, voter2.address);

  // 6. 初始状态查询
  const c1 = await voteContract.getCandidate(1);
  console.log(`候选人 #1: c1.name,当前票数:{c1.name}, 当前票数:{c1.voteCount}`);
}

// 优雅处理异步异常
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

执行部署:

bash
npx hardhat run scripts/deploy.js --network hardhat

控制台应依次输出部署地址、候选人注册信息与测试选民地址。

17.3.4 使用 Hardhat Console 本地交互验证

脚本部署后,接下来进入交互式验证阶段。Hardhat 提供了 console 子命令,允许开发者在已部署合约上逐条执行函数调用,非常适合教学与调试。

首先,在另一个终端启动一个长期运行的本地节点(模拟真实区块链,MetaMask 也能够连接):

bash
npx hardhat node

然后打开 Hardhat Console 并连接到该本地节点,加载已部署的合约实例:

bash
npx hardhat console --network localhost

> 提示符下,你可以执行以下操作来验证业务逻辑:

javascript
// 加载已部署的合约(替换为 deploy.js 输出的实际地址)
const Vote = await ethers.getContractFactory("Vote");
const vote = await Vote.attach("0x5FbDB2315678afecb367f032d93F642f64180aa3");

// 获取可用签名者
const [owner, voter1, voter2, stranger] = await ethers.getSigners();

// 查询候选人信息
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 0n ]

// 以 voter1 身份投票
await vote.connect(voter1).vote(1);
// 交易确认后,voter1 成功投票给候选人 1

// 再次查询票数
await vote.getCandidate(1);
// 返回: [ 1n, 'Alice - 建设更多公共节点', 1n ]

// 尝试重复投票(应被回滚)
await vote.connect(voter1).vote(2);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: already voted'

// 以未授权地址投票(应被回滚)
await vote.connect(stranger).vote(1);
// 抛出: Error: VM Exception while processing transaction: reverted with reason string 'Vote: caller is not an authorized voter'

// 以 voter2 投票给不同候选人
await vote.connect(voter2).vote(3);
await vote.getCandidate(3);
// 返回: [ 3n, 'Charlie - 引入 DAO 治理', 1n ]

下图以时序图的形式展示了上述交互的完整流程:

sequenceDiagram
    participant U as 开发者
    participant C as hardhat console
    participant N as Hardhat Network
    participant S as Vote.sol 合约
    participant E as 链上存储

    U->>C: npx hardhat console --network localhost
    C->>N: 请求合约实例
    N->>C: 返回 Vote 合约句柄
    U->>C: vote.connect(voter1).vote(1)
    C->>N: 发送交易
    N->>S: 执行 vote(1)
    S->>S: require(authorized)
    S->>S: require(!voted)
    S->>E: voted[voter1] = true
    S->>E: candidates[1].voteCount++
    S->>N: emit Voted
    N->>C: 交易回执
    C->>U: 返回成功
    U->>C: vote.connect(voter1).vote(2)
    C->>N: 发送交易
    N->>S: 执行 vote(2)
    S->>S: require(!voted) ❌ 失败回滚
    N->>C: 抛出 revert
    C->>U: 显示 'Vote: already voted'

这个交互过程充分验证了合约的两大核心安全属性:

  1. 已授权选民可以成功投票,且票数正确累加。
  2. 重复投票与未授权投票均被合约级 revert 拦截,不会污染状态。

Hardhat Console 是这一验证过程的利器——它比写前端更快,比直接写测试用例更灵活,让开发者可以在任何阶段停下来检查合约内部状态。

本节要点

  • 工程化四步法:初始化npm install(Hardhat + OpenZeppelin) → 编译hardhat compile) → 部署脚本hardhat run) → 本地交互/测试
  • hardhat.config.js 通过 optimizer 配置降低部署与执行成本。
  • 部署脚本不仅是「启动合约」,还应包含「预填充数据」,降低后续交互的重复工作。
  • npx hardhat console 提供了与已部署合约实时交互的 REPL 环境,是调试与验证的最佳入口。

章末小结:3 个关键认知

  1. 安全不是附加功能,而是设计前提。从威胁模型出发,我们在编码之前就已经明确了「防重投、防非授权投票、防管理员作恶」三大防御目标,这些目标直接映射为 voted 映射、authorizedVoters 白名单与 Ownable 修饰符。先想「坏人怎么做」,再写「代码怎么防」,是合约开发的正确顺序。
  2. Checks-Effects-Interactions 是防重入的黄金铁律vote() 函数将 voted[msg.sender] = true 这一状态更新放在任何可能被攻击的外部调用(如未来扩展的代币奖励)之前。这一顺序原则不仅适用于投票,也适用于所有涉及状态变更与外部调用的智能合约函数。
  3. Hardhat 工程化让「编译-部署-交互-测试」形成高速闭环。从 hardhat compilehardhat run scripts/deploy.js 再到 hardhat console,整个流程在本地几秒内就能完成,无需等待测试网区块确认。这种快速反馈是 DApp 开发效率的核心保障。

附录:项目目录树

将本章所有代码整理后,完整的文件结构如下:

text
voting-dapp/
├── contracts/
│   └── Vote.sol              # 投票主合约(Solidity 0.8.19)
├── scripts/
│   └── deploy.js             # 部署与初始化脚本
├── test/
│   └── Vote.test.js          # 单元测试(将在后续章节展开)
├── node_modules/             # 依赖包(Hardhat、OpenZeppelin 等)
├── hardhat.config.js         # 编译器与网络配置
├── package.json
└── package-lock.json

17.4 合约单元测试与Gas优化

17.4.1 单元测试覆盖场景

智能合约一经部署便不可修改,因此单元测试是 DApp 开发的"最后一道防线"。对于投票系统,必须验证以下核心场景:

  • 成功填充选举信息
  • 授权选民(选民白名单维护)
  • 合法投票且正确计票
  • 非法场景回滚:重复投票、非授权选民投票、非候选人投票

Hardhat 提供了与 Mocha / Chai 无缝集成的测试框架,并针对智能合约扩展了语义化的 matchers。我们的测试套件按如下流程执行:

flowchart TD
    A[hardhat test] --> B[是否要顺序执行?]
    B -->|是| C[beforeEach 重置快照]
    C --> D[部署 Voting 合约]
    D --> E[授权测试账户为选民]
    E --> F[获取合约实例与 Signer]
    F --> G[执行测试用例]
    G --> H[expect 结果与预期
  致?]
    H -->|是| I["✅测试通过,下一用例"]
    I -->|否| J[回滚至快照,清理状态]
    J --> K[执行下一用例]

每一条测试路径都保证独立、可重复运行,通过 beforeEach 钩子每次重新部署合约,避免测试间状态污染。

17.4.2 测试核心代码

javascript
// test/Voting.test.js
const { expect } = require("chai");

describe("Voting 合约测试", function () {
  let voting;
  let owner, voterA, voterB, attacker;

  // 每用例重置,避免状态污染
  beforeEach(async () => {
    [owner, voterA, voterB, attacker] = await ethers.getSigners();
    const VotingFactory = await ethers.getContractFactory("Voting");
    voting = await VotingFactory.deploy(["Alice", "Bob"]);
    await voting.setVoterStatus(voterA.address, true);
  });

  it("应创建候选人", async () => {
    const candidates = await voting.getCandidates();
    expect(candidates).to.deep.equal(["Alice", "Bob"]);
  });

  it("授权选民可投票并能正确增量", async () => {
    await voting.connect(voterA).vote(0);
    const count = await voting.votes(0);
    expect(count).to.equal(1);
  });

  it("非法选民尝试投票应回滚", async () => {
    await expect(
      voting.connect(attacker).vote(0)
    ).to.be.revertedWith("Not authorized voter");
  });

  it("重复投票应回滚", async () => {
    await voting.connect(voterA).vote(0);
    await expect(
      voting.connect(voterA).vote(0)
    ).to.be.revertedWith("Already voted");
  });
});

关键认知hardhat-chai-matchers 插件提供的 revertedWith 可精确校验回滚消息,让测试可读性大幅提升。to.equal 对应基础断言,to.deep.equal 用于数组/结构化数据比对。

17.4.3 Gas优化的权衡

以太坊主网的存储操作(SSTORE)极为昂贵,单次写入动辄花掉 20,000 gas。针对投票系统的优化空间:

  • 状态变量打包:将独立但相关的变量尽量定义为同一结构体内部字段,Solidity 编译器会尝试紧凑排列。例如 uint8 id + uint8 voteCount 可被打包进 1 个 32 字节槽位,而不是各占 1 槽。
  • 事件替代存储:如果仅需事后审计、不需要合约逻辑读取,可用事件(event Voted(address, uint8, uint))替代链上状态更新。事件写入成本约为存储操作的 1/5 至 1/8。
  • 减少变量类型级别的"过度分配":使用 uint8 替代 uint256 可节省空间,但如果该变量会频繁参与运算导致额外的类型扩展开销,反而得不偿失。权衡原则是:"仅在冷存储(写入为主、运算少)场景使用小类型"。

下表列出未优化与优化后的单笔 vote() 函数 Gas 消耗对比(基于 Hardhat Network 快照):

操作未优化(uint256/映射)优化(uint8/结构体打包)仅事件记录
部署合约424,801398,213412,005
单次投票66,43259,82112,340

注意:上述对比数据在本地 Hardhat 网络测得,仅用于趋势教学,不反映主网实际价格波动。

17.4.4 要点总结

  • beforeEach 钩子确保测试独立,避免跨测试用例状态污染。
  • revertedWith 配合清晰错误信息,可同时验证业务逻辑与错误处理链路。
  • 事件是降低链上写入成本的首选方案,适用于审计类需求;若需合约内部直接读取,则仍需存储状态。
  • 小类型(uint8)的 Gas 收益仅在"冷存储"场景显著,频繁参与运算时可能得不偿失。

17.5 使用 ethers.js 与 React 构建前端

17.5.1 项目架构与数据流

前端作为用户与智能合约交互的入口,需要清晰的分层数据流。我们从组件架构开始,逐步向下连接合约层:

graph TD
    A[App.jsx] --> B[WalletConnect]
    A --> C[ElectionInfo]
    A --> D[CandidateList]
    A --> E[VoteButton]
    A --> F[ResultsChart]
    B -->|账号+签名者状态| A
    D -->|候选人列表| E
    E -->|投票请求| A
    A -->|展示票数| F

核心设计原则:只读(Read)与写入(Write)分离。读取选举信息、候选人列表等操作无需钱包授权,仅需调用 provider 上的只读方法;投票等写入操作则需要用户签名,需获得 signer。通过自定义 useContract Hook,我们可以在全局高效切换这两种模式。

17.5.2 自定义 Hook:useContract

javascript
// src/hooks/useContract.js
import { useMemo } from "react";
import { Contract, JsonRpcProvider, BrowserProvider } from "ethers";
import abi from "../artifacts/contracts/Voting.sol/Voting.json";

const CONTRACT_ADDRESS = "0x..."; // 部署后替换

export function useContract(signer = null) {
  return useMemo(() => {
    if (!signer) return null;
    // signer 模式下以写入
    const contract = new Contract(CONTRACT_ADDRESS, abi.abi, signer);
    return contract;
  }, [signer]);
}

export function useReadContract() {
  return useMemo(() => {
    const provider = new JsonRpcProvider("https://rpc.sepolia.org");
    return new Contract(CONTRACT_ADDRESS, abi.abi, provider);
  }, []);
}

Hook 职责分离useReadContract 以无签名的 RPC 调用,不需要 MetaMask 授权即可展示基础数据;useContract 将用户签名者(Signer)传递给合约实例,实现写入权限管理。

17.5.3 候选人列表组件(只读模式)

jsx
// src/components/CandidateList.jsx
import { useEffect, useState } from "react";
import { useReadContract } from "../hooks/useContract";

export default function CandidateList() {
  const [candidates, setCandidates] = useState([]);
  const contract = useReadContract();

  useEffect(() => {
    if (!contract) return;
    const fetch = async () => {
      const list = await contract.getCandidates();
      setCandidates(list);
    };
    fetch();
  }, [contract]);

  return (
    <ul className="candidate-list">
      {candidates.map((name, idx) => (
        <li key={idx}>
          <span className="id">{idx}</span> {name}
        </li>
      ))}
    </ul>
  );
}

17.5.4 投票按钮组件(写入模式 + 异步状态管理)

jsx
// src/components/VoteButton.jsx
import { useState } from "react";

export default function VoteButton({ contract }) {
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
  const [tx, setTx] = useState(null);

  const vote = async (candidateId) => {
    if (!contract) return;
    setError(null);
    setTx(null);
    setLoading(true);
    try {
      const txReq = await contract.vote(candidateId);
      setTx(`广播中... Hash: ${txReq.hash.slice(0, 12)}...`);
      const receipt = await txReq.wait();
      setTx(`确认成功!区块号: ${receipt.blockNumber}`);
    } catch (err) {
      setError(err.code === 4001 ? "用户取消了交易" : err.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      <button onClick={() => vote(0)} disabled={!contract || loading}>
        {loading ? "投票中..." : "投票给 Alice"}
      </button>
      {tx && <p className="success">✅ {tx}</p>}
      {error && <p className="error">❌ {error}</p>}
    </div>
  );
}

前端交互时序如下:

sequenceDiagram
    actor User
    participant Button as VoteButton.jsx
    participant Hook as useContract
    participant MetaMask as 钱包扩展
    participant EVM as 以太坊节点

    User->>Button: 点击"投票"
    Button->>Hook: 调用 contract.vote(id)
    Hook->>MetaMask: 发起 eth_sendTransaction
    MetaMask->>User: 弹出签名窗口
    User->>MetaMask: 确认交易
    MetaMask->>EVM: 提交已签交易
    EVM-->>MetaMask: 返回 TX Hash
    MetaMask-->>Hook: 返回 Transaction 对象
    Hook-->>Button: 设置 tx Hash 状态
    Button-->>User: 显示 "广播中..."
    EVM->>EVM: 挖矿确认(等待...)
    EVM-->>MetaMask: 确认回调
    MetaMask-->>Hook: 交易回执(receipt)
    Hook-->>Button: 设置 confirmed 状态
    Button-->>User: 显示 "✅ 确认成功"

异步三态原则:所有涉及交易的 UI 必须显式管理 loading / error / success 三种状态。没有反馈的按钮会让用户误以为操作成功或失败,导致重复触发交易(重复投票)。

17.5.5 要点总结

  • 前端与合约交互应按 只读 / 写入 拆分为两类 Hook,避免在不需要时弹出钱包授权。
  • ethers.Contract 创建开销极小,但仍应在 useMemo 中缓存,避免依赖项不变时重复实例化。
  • 所有与交易相关的按钮必须显示三个状态:加载中、成功反馈、错误提示。
  • Mermaid 组件树图可将复杂的前端层级以可视化的方式呈现,便于团队协作与架构评审。

17.6 钱包连接与签名交互

17.6.1 检测与连接 MetaMask

钱包插件通过浏览器注入的 window.ethereum 对象提供接口。主流前端第一步:检测对象是否存在,若缺失则给出明确的安装引导。

jsx
// src/components/WalletConnect.jsx
import { useState } from "react";
import { BrowserProvider } from "ethers";
import { useContract } from "../hooks/useContract";

export default function WalletConnect({ onConnect }) {
  const [status, setStatus] = useState("未连接");
  const [address, setAddress] = useState(null);

  const connectWallet = async () => {
    if (typeof window.ethereum === "undefined") {
      setStatus("未检测到兼容钱包,请安装 MetaMask");
      return;
    }
    try {
      const provider = new BrowserProvider(window.ethereum);
      const accounts = await provider.send("eth_requestAccounts", []);
      const signer = await provider.getSigner();
      setAddress(accounts[0]);
      setStatus("已连接");
      onConnect(signer);
    } catch (err) {
      if (err.code === 4001) {
        setStatus("用户拒绝了账户授权");
      } else {
        setStatus("连接失败: " + err.message);
      }
    }
  };

  return (
    <div className="wallet-connect">
      <p>状态: <b>{status}</b></p>
      {address && <p>地址: {address.slice(0, 10)}...{address.slice(-6)}</p>}
      <button onClick={connectWallet}>连接钱包</button>
    </div>
  );
}

17.6.2 网络切换与链 ID 校验

测试网(Sepolia)与主网的链 ID 不同,前端需确保用户连接到了目标网络。不匹配时主动调用 wallet_switchEthereumChain 切换。

javascript
// 网络配置映射
const SUPPORTED_CHAIN = 0xaa36a7; // Sepolia 测试网
const SEPOLIA_RPC = "https://rpc.sepolia.org";

async function checkAndSwitchNetwork() {
  const chainId = await window.ethereum.request({ method: "eth_chainId" });
  if (Number(chainId) !== SUPPORTED_CHAIN) {
    try {
      await window.ethereum.request({
        method: "wallet_switchEthereumChain",
        params: [{ chainId: `0x${SUPPORTED_CHAIN.toString(16)}` }],
      });
    } catch (err) {
      if (err.code === 4902) {
        // 用户未添加目标网络,请求添加
        await window.ethereum.request({
          method: "wallet_addEthereumChain",
          params: [{
            chainId: `0x${SUPPORTED_CHAIN.toString(16)}`,
            chainName: "Sepolia 测试网",
            rpcUrls: [SEPOLIA_RPC],
          }],
        });
      } else {
        throw err;
      }
    }
  }
}

钱包连接的状态机可用以下状态图描述:

stateDiagram
    [*] --> 未连接
    未连接 --> 已连接: 检测到钱包
    已连接 --> 网络错误: 链 ID 不匹配
    网络错误 --> 已连接: 切换网络成功
    已连接 --> 交易进行中: 发送交易
    交易进行中 --> 已连接: 交易结果返回
    已连接 --> 未连接: 钱包断开/锁定
    未连接 --> [*]

17.6.3 交易发送与 Gas 估算

javascript
// 带 Gas Limit 估算的投票交易发送
const sendVoteTransaction = async (contract, candidateId) => {
  const txReq = await contract.vote.populateTransaction(candidateId);
  const gasEstimate = await contract.runner.estimateGas(txReq);
  const gasLimit = (gasEstimate * 120n) / 100n; // 增加 20% 安全余量

  const tx = await contract.vote(candidateId, { gasLimit });
  return tx.wait();
};

Gas 安全策略:直接预估值发送存在竞争条件下高失败风险。增加 10%–20% 余量可显著降低交易被打回(revert)概率,同时避免过度浪费。若设置过大,多余 gas 不会被消耗,仅影响最大上限。

17.6.4 离线签名(高阶选学)

去中心化应用的某些场景(如链下数据登记、轻量级身份认证)不需要交易上链,只需证明某地址拥有对应私钥即可。

javascript
// 投票前的链下身份验证(可选)
async function signAuthorization(signer, account, candidateId) {
  const message = `I authorize my address {account} to vote for candidate #{candidateId}`;
  const signature = await signer.signMessage(message);
  return { account, message, signature };
  // 后端验证:ethers.utils.verifyMessage(message, signature) === account
}

链下签名零 Gas、几乎即时生效,适用于需要高速确认的业务。投票 DApp 主流程不在链下完成,但可以引入"链下预授权 + 可信后端代投"模式降低前端摩擦。

17.6.5 要点总结

  • window.ethereum 的检测是必做步骤,未安装钱包时应提供清晰的安装引导而非报错。
  • eth_requestAccounts 弹出授权窗口时,务必捕获 4001 错误码,区分"用户拒绝"与"系统错误",提升交互体验。
  • 链 ID 校验 + wallet_switchEthereumChain 切换是用户 onboarding 的"防坑"关键,避免用户误在主网支付真实 Gas。
  • estimateGas + 20% 安全余量的发送模式,能显著降低交易失败率。
  • 离线签名是补全前端工具箱的高阶技能,在认证与快速确认场景下尤其实用。

总结:三阶段模型的闭环

本章将投票 DApp 从合约"黑盒"延伸为可交互的完整应用:

  1. 17.4 单元测试 建立了后端安全底线,Gas 优化让我们理解链上资源的真实成本。
  2. 17.5 前端架构 通过 React 组件树与自定义 Hook,将复杂合约接口转化为清晰、可复用的 UI 层。
  3. 17.6 钱包交互 打通了用户与区块链之间的"最后一公里",从验权、网络切换到安全交易发送,确保每一次投票都可靠可追溯。

这三个环节共同构成了一条从代码到用户的完整链路。下一章,我们将进一步拓展:监听合约事件以实时更新投票结果图表,并最终将 DApp 部署至公共测试网。

17.7 监听合约事件与实时更新UI

17.7.1 为什么需要链上事件?

智能合约运行在EVM中,由于区块链的封闭性,合约无法主动「推送」消息给前端。Solana的做法是让前端轮询账户状态,而以太坊提供了更优雅的机制——事件(Event)

事件通过LOG0-LOG4操作码写入交易收据(Receipt)的日志字段。事件的特点是:

  • 不可合约内读取:同一EVM内的其他合约无法消费你发出的事件
  • 链下永久可查:任何人通过节点RPC可以检索历史事件
  • Gas成本低:写入日志比写入存储(SSTORE)便宜得多

在投票合约中,我们定义了Voted事件:

solidity
event Voted(address indexed voter, uint indexed candidateId, uint timestamp);

这里的indexed关键字至关重要:索引参数(最多3个)会被放入topic[1]-topic[3]中,支持前端按主题过滤。非索引参数(如timestamp)则放在data字段。

17.7.2 ethers.js 事件监听实战

前端使用ethers.js监听Voted事件的核心代码:

jsx
// hooks/useEventListener.js
import { useEffect, useRef } from 'react';
import { ethers } from 'ethers';

export function useEventListener(contract, eventName, callback) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback; // 保证闭包中的callback始终最新

  useEffect(() => {
    if (!contract) return;

    const handler = (...args) => {
      const event = args[args.length - 1]; // 最后一个参数是Event对象
      callbackRef.current(args.slice(0, -1), event);
    };

    contract.on(eventName, handler);

    return () => {
      contract.off(eventName, handler); // 组件卸载时必须清理!
    };
  }, [contract, eventName]);
}

⚠️ 常见陷阱:忘记在useEffect返回值中清理监听会导致内存泄漏。如果组件频繁挂载/卸载而不清理,最终会堆积大量重复监听,造成性能下降甚至事件重复响应。

在投票组件中使用:

jsx
useEventListener(contract, 'Voted', (args, event) => {
  const [voter, candidateId, timestamp] = args;
  setVotes(prev => ({
    ...prev,
    [candidateId.toString()]: (prev[candidateId.toString()] || 0) + 1,
  }));
});

17.7.3 三种状态同步策略对比

策略延迟Gas开销实现复杂度推荐场景
轮询高(~12s/次)无(只读RPC不消耗Gas)对实时性要求低的展示页
事件驱动低(出块后即时)实时DApp交互页
The Graph子图中(索引延迟数秒)复杂历史数据查询

事件驱动是投票DApp的首选方案:用户投票后,事件在同一个区块被发出,前端几乎实时收到通知。但页面刷新后,初始数据仍需通过合约只读调用获取:

jsx
// 页面加载时拉取最新状态
useEffect(() => {
  if (!contract) return;
  const loadCandidates = async () => {
    const count = await contract.getCandidateCount();
    // ... 遍历并拉取每个候选人数据
  };
  loadCandidates();
  // 后续更新由事件驱动
}, [contract]);

17.7.4 结果可视化组件

使用 recharts 库展示投票结果:

jsx
import { BarChart, Bar, XAxis, YAxis, Tooltip } from 'recharts';

function ResultsChart({ candidates }) {
  const data = candidates.map(c => ({
    name: c.name,
    得票数: c.voteCount,
  }));

  return (
    <BarChart width={600} height={300} data={data}>
      <XAxis dataKey="name" />
      <YAxis />
      <Tooltip />
      <Bar dataKey="得票数" fill="#8884d8" />
    </BarChart>
  );
}

Voted事件触发时,React状态更新会立即反映到柱状图上,实现实时票数增长动画。

17.7.5 Mermaid图表:事件驱动流程

sequenceDiagram
    participant User as 投票者
    participant Frontend as React前端
    participant Contract as 投票合约(EVM)
    participant Event as 链上事件日志

    User->>Frontend: 点击投票按钮
    Frontend->>Frontend: 乐观更新UI(先+1)
    Frontend->>Contract: sendTransaction(vote(1))
    Contract-->>Event: 触发Voted(voter, 1, timestamp)
    Note over Contract,Event: LOG1操作码写入收据
    Event->>Frontend: contract.on('Voted', ...)
    Frontend->>Frontend: 核对与乐观更新一致✓
    Frontend->>User: 实时刷新柱状图
    Note over Frontend: 若交易失败,回滚乐观更新

17.7.6 本节要点

  • 事件是连接链上状态与前端UI的核心桥梁,比轮询更高效
  • indexed参数支持前端按主题过滤,最多3个索引参数
  • 前端必须管理事件监听的生命周期,防止内存泄漏
  • 乐观更新提升交互体验,但需做好交易回滚时的状态恢复

17.8 部署至测试网与合约验证

17.8.1 测试网选型与环境准备

2025年,以太坊的主要测试网是Sepolia。它取代了已弃用的Goerli和Ropsten,是合约开发的标准测试环境。

准备工作:

  1. 获取RPC端点:注册Infura或Alchemy,创建一个Sepolia项目,获取HTTPS端点URL
  2. 获取测试ETH:访问 Sepolia Faucet(如Alchemy Faucet),输入你的测试网地址领取测试ETH
  3. 管理私钥:创建.env文件,配置环境变量
text
# .env — 禁止提交到Git!
PRIVATE_KEY=0x你的测试网私钥(不含0x前缀)
INFURA_API_KEY=你的Infura项目ID
ETHERSCAN_API_KEY=你的Etherscan API Key

17.8.2 Hardhat网络配置

hardhat.config.ts中添加Sepolia网络配置:

typescript
import { HardhatUserConfig } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';
import * as dotenv from 'dotenv';

dotenv.config();

const config: HardhatUserConfig = {
  solidity: '0.8.20',
  networks: {
    sepolia: {
      url: `https://sepolia.infura.io/v3/${process.env.INFURA_API_KEY}`,
      accounts: [process.env.PRIVATE_KEY!],
      // EIP-1559: 交易类型2的参数
      maxFeePerGas: 100_000_000_000n,   // 100 gwei
      maxPriorityFeePerGas: 5_000_000_000n, // 5 gwei
    },
  },
  etherscan: {
    apiKey: process.env.ETHERSCAN_API_KEY,
  },
};

export default config;

17.8.3 部署脚本

javascript
// scripts/deploy.js
const hre = require('hardhat');

async function main() {
  const [deployer] = await hre.ethers.getSigners();
  console.log('部署账户:', deployer.address);

  const candidates = ['Alice', 'Bob', 'Charlie'];
  const Election = await hre.ethers.getContractFactory('Election');
  const election = await Election.deploy(candidates);

  await election.waitForDeployment();

  const addr = await election.getAddress();
  console.log('选举合约已部署至:', addr);
  console.log('候选人:', candidates.join(', '));

  // 输出验证命令
  console.log('\n运行验证命令:');
  console.log(`npx hardhat verify --network sepolia addr"{addr} "{candidates.join('","')}"`);
}

main().catch(console.error);

执行部署:

bash
npx hardhat run scripts/deploy.js --network sepolia

⚠️ 常见错误:如果提示insufficient funds,说明账户中测试ETH不足,需要去Faucet补充。

17.8.4 合约验证

部署完成后,在Etherscan上验证合约源码:

bash
npx hardhat verify --network sepolia <合约地址> "Alice" "Bob" "Charlie"

验证成功后,用户可以在Etherscan上看到:

  • 完整的合约源码(Solidity原文件或扁平化后的版本)
  • 自动生成的Read Contract和Write Contract交互界面
  • ABI和字节码的公开记录

这对增强DApp透明度和用户信任至关重要。

17.8.5 前端部署到Vercel

前端集成验证后的合约地址,然后部署到Vercel:

  1. 将前端代码推送到GitHub仓库
  2. 在Vercel控制台导入该仓库
  3. 设置构建命令:npm run build
  4. 添加环境变量(无需私钥):
  • VITE_CONTRACT_ADDRESS:验证后的合约地址
  • VITE_RPC_URL:Alchemy/Infura的Sepolia RPC端点
  1. 部署完成,获得可公开访问的URL
flowchart LR
    A[编写合约] -->|本地测试| B[Hardhat测试通过]
    B --> C[配置Sepolia网络]
    C --> D[获取测试ETH]
    D --> E[部署到Sepolia]
    E --> F[Etherscan验证]
    F --> G[更新前端合约地址]
    G --> H[Vercel部署前端]
    H --> I[全栈DApp上线]

17.8.6 本节要点

  • Sepolia是当前以太坊主流测试网,替代已弃用的Goerli
  • .env文件管理私钥和API Key,必须加入.gitignore
  • 合约验证提高透明度和用户信任,是高质量DApp的必要步骤
  • 前端部署只需合约地址和RPC端点,无需私钥

17.9 完整项目复盘

17.9.1 项目目录结构

一个完整的投票DApp项目结构如下:

text
voting-dapp/
├── contracts/                    # Solidity源代码
│   ├── Election.sol              # 核心投票合约
│   └── VoterRegistry.sol         # 选民注册合约
├── test/
│   └── election.test.js          # 合约单元测试
├── scripts/
│   ├── deploy.js                 # 部署脚本
│   └── verify.js                 # 验证脚本
├── frontend/
│   ├── src/
│   │   ├── App.jsx               # 主应用入口
│   │   ├── components/
│   │   │   ├── WalletConnect.jsx  # 钱包连接
│   │   │   ├── ElectionInfo.jsx   # 选举信息展示
│   │   │   ├── CandidateList.jsx  # 候选人列表
│   │   │   ├── VoteButton.jsx     # 投票按钮
│   │   │   └── ResultsChart.jsx   # 结果图表
│   │   ├── hooks/
│   │   │   ├── useContract.js     # 合约交互Hook
│   │   │   └── useEventListener.js# 事件监听Hook
│   │   └── utils/
│   │       └── constants.js       # 合约地址/ABI常量
│   ├── package.json
│   └── vite.config.js
├── hardhat.config.ts
├── .env
└── README.md

17.9.2 关键架构决策回顾

为什么使用工厂模式设计Election合约?

工厂模式允许一次部署创建一个「选举工厂」,后续可以通过工厂合约的方法创建多轮独立的选举。这比在合约中硬编码候选人列表更灵活,也便于扩展。

为什么要分离VoterRegistry合约?

将选民注册逻辑从Election合约中分离出来,使得投票权限管理可以与投票本身解耦。例如,你可以用同一个VoterRegistry管理多轮选举的选民资格,或者在未来将注册逻辑替换为ERC-20余额快照。

为什么选择事件驱动而非轮询?

投票DApp对实时性有一定要求:用户投票后期待立即看到票数变化。轮询每12秒(一个区块时间)拉取一次数据,延迟感明显。事件驱动在区块被挖出的瞬间就将新数据推送到前端,体验更接近Web2应用。

前端状态管理选型思考

对于投票DApp这种中等复杂度的场景,React Context + useState/useReducer完全足够。引入Redux会增加不必要的样板代码。如果未来需要管理更复杂的状态(如多轮选举、用户Session、链上通知),可以考虑Zustand或Jotai这类轻量状态管理库。

页面刷新后的状态恢复

所有状态最终由链上数据驱动。当用户刷新页面时:

  1. 前端重新连接钱包(MetaMask自动恢复session)
  2. 合约只读方法拉取最新数据(候选人列表、票数)
  3. 事件监听重新启动,接收后续更新

这一模式称为「Source of Truth on-chain」——前端只是链上状态的缓存。

17.9.3 开发中的常见陷阱

陷阱现象解决方案
MetaMask网络切换调用交易时网络不匹配监听chainChanged事件,刷新页面或提示切换
ABI版本冲突前端用旧ABI调用新版合约部署后从artifacts/复制最新ABI
事件监听内存泄漏多次调用合约方法,UI性能下降useEffect返回值中调用contract.off()
Gas估算失败交易长时间pending手动设置gasLimit参数
异步错误吞没交易失败但无提示使用.catch()或在try/catch中捕获并展示
本地与测试网地址混淆前端连接本地合约地址而非测试网使用.env管理网络相关配置

17.9.4 作业与延伸方向

如果你已完成本节内容,以下是值得继续探索的方向:

  1. 添加时间锁
  • 在Election合约中添加votingDeadline变量
  • 投票函数增加时间检查:require(block.timestamp < votingDeadline)
  • 前端显示投票倒计时
  1. 加权投票(基于ERC-20余额)
  • 引入ERC-20代币地址作为投票权重依据
  • 投票时读取调用者的代币余额作为票数权重
  • 需注意防止闪电贷操纵快照余额
  1. 链上委托投票
  • 实现delegate(address to)函数
  • 将投票权委托给他人,类似Compound的治理模型
  1. DAO风格治理
  • 引入多签国库(Gnosis Safe)
  • 实现提案创建 → 投票 → 执行的三阶段流程

17.9.5 完整项目架构图

flowchart TB
    subgraph 合约层
        EC[Election 合约] --- VR[VoterRegistry 合约]
    end
    subgraph 区块链基础设施
        RPC[RPC节点/Infura] --- ES[Etherscan]
    end
    subgraph 前端层
        WC[WalletConnect 组件]
        EI[ElectionInfo 组件]
        CL[CandidateList 组件]
        VB[VoteButton 组件]
        RC[ResultsChart 组件]
        HL[useEventListener Hook]
    end
    subgraph 用户
        V[投票者]
        AD[管理员]
    end

    V <-->|MetaMask| WC
    AD -->|部署/注册| EC
    EC <-->|合约事件| HL
    HL --> EI
    HL --> RC
    VB -->|sendTransaction| EC
    RC -->|只读调用| EC
    EC -->|已验证| ES
    V --> VB
    AD --> VR

本章要点

  1. 全栈DApp = 合约 + 工具链 + 前端 + 部署:四条腿缺一不可,每条腿都是一套独立的工程知识体系
  2. 事件驱动是DApp实时交互的基础:理解EVM日志机制、ethers.js事件API、前端生命周期管理,是区分Web2前端开发者和Web3全栈开发者的标志性能力
  3. 测试网部署是将DApp推向真实的必经之路:本地开发环境与测试网环境的差异(RPC限速、出块时间、Gas价格)必须在早期暴露
  4. 项目复盘比项目本身更重要:回顾架构决策、总结开发陷阱、规划延伸方向,决定了你是做完一个项目还是真正理解了一个项目

评论

0

评论加载中…

发表评论

0/2000